fix(mcp): contribute Connect an Agent into the Account app nav so a non-admin can mint their own key - #17646
Conversation
`POST /api/v1/keys` mints a `sys_api_key` bound to the caller and the page says the key "acts as you", but the only nav entry sat in Setup behind `requiredPermissions: ['setup.access']` — so every non-admin following the two-step guide stopped at step 1 while the endpoint behind the button accepted them all along. Adds a second `navigationContributions` entry in the same bundle, targeting the `account` app's `grp_account_developer` group beside the `nav_account_api_keys` entry already shipping there. The Setup entry stays for admins. Backend, authorization and the published "acts as you" promise do not move, and no gate of any kind is added or removed: a contribution registers exactly when the page registers. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
…Setup guard Pins the half this package owns: the contribution is aimed at the ungated `account` app / `grp_account_developer` group, its item carries nothing the server-side nav filter could strip for a permissionless caller, the Setup entry is byte-unchanged, and — the load-bearing one — this bundle declares no permission key and no `apps` collection, so it cannot reach the card by widening Setup instead. Both contributions are parsed against the real `NavigationContributionSchema`, which is what makes the shared item id an accepted fact rather than an unenforced one. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
…an-Agent entry Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude <noreply@anthropic.com>
…nnect-agent-account-nav
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 743a70a30577f20c3938598c4805bd0e0c9cca1f && git checkout 743a70a30577f20c3938598c4805bd0e0c9cca1f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1f0b5659e430717645877f01c4128be567731e35 32f62417bdac521233be5445f9e657a607146558 && git checkout -B drift-repro 1f0b5659e430717645877f01c4128be567731e35 && git merge --no-ff 32f62417bdac521233be5445f9e657a607146558
node scripts/docs-audit/affected-docs.mjs --json 1f0b5659e430717645877f01c4128be567731e35
|
Fixes #16746
Clause-②: no
Declared by the dispatching
domain:cliPM seat (sessionsession_01TSf4DV7ziu4V5j73e46b7c), matching the claim comment on #16746. Verified against the DIFF: the payload gains one array ELEMENT of an existing shape, not a new key —NavigationContributionSchemais untouched and both contributions parse against the real contract (pinned in this PR). 0 new error codes. The permission-VISIBILITY aspect is deliberately not clause-②: a runtime permission/security behaviour change sits on the manual floor, and that floor was discharged by the maintainer ruling of 2026-09-08 (decision batch #85, option A, verbatim 「其他同意」), not by this declaration.What changed
CONNECT_AGENT_UI_BUNDLE(packages/mcp/src/connect-ui.ts) now carries a secondnavigationContributionsentry, the near-twin of the existing one, targeting theaccountapp's
grp_account_developergroup. The Setup entry is byte-unchanged, so admins keep the pagewhere the shipped guide and the runtime's own error text point.
Nothing else moves: no backend change, no authorization change, no change to which permissions
exist, no gate added or removed anywhere, and
packages/platform-objectsis targeted by name,never edited. Executes the recorded ruling (option A, director seat decision batch #85,
2026-09-08, maintainer verbatim 「其他同意」).
Why this shape and not the others — each refused on measurement
group_integrations)setup.accessgate fires before the group gate, so dropping the group gate alone changes nothing; dropping both serves 14+ unrelated Setup surfaces (Users, Organization, Business Units, Branding, Feature Flags, …) to every signed-in user — far past what the ruling asked foraccount.app.tsentry gated onrequiresService: 'mcp'mcpservice registers unconditionally ininit()while this bundle registers behindisMcpServerEnabled()— so the entry outlives its page and 404s for every signed-in user onOS_MCP_SERVER_ENABLED=false{ type: 'component', componentRef: 'mcp:connect-agent' }mcp:connect-agentlives in objectui's SDUI widget registry, not the app-component registry those nav items resolve throughA navigation contribution registers exactly when the page registers, which is why both
entries live in this one bundle and neither carries a gate of its own — an opted-out deployment
still gets no page and neither entry.
The item-id question, answered from the fold
Both entries deliberately share the item id
nav_connect_agent. That is scoped, not acollision, read off
SchemaRegistryrather than assumed: contributions are keyed by targetapp (
appNavContributions, a Map keyed by app name) andapplyNavContributions(app)consults onlyget(app.name), so a nav item id is unique within one app's navigation tree. Nothing indexesit across apps — no id-keyed registry, no de-duplication by id — and the translation bundles are
keyed
apps.APP.navigation.NAV_ID, which makes one shared id two distinct keys. One destinationkeeps one identity.
The two items are separate object literals rather than one shared const: the fold
structuredClones the app but pushes...c.itemsby reference, so a shared literal wouldsit in two apps' navigation trees at once.
Acceptance — both halves measured over the real composition
Real
SETUP_APP/ACCOUNT_APP/SETUP_NAV_CONTRIBUTIONS, the realCONNECT_AGENT_UI_BUNDLE, the real fold and the real RBAC-by-route filter (RestServerwiththis repo's established stub-the-exec-context pattern from
packages/rest/src/meta-app-publish-gate.test.ts), driven frompackages/cli— the one packagethat depends on all four:
GET /api/v1/meta/apps/accountgrp_account_developerchildren =['nav_account_api_keys', 'nav_account_oauth_apps', 'nav_connect_agent']GET /api/v1/meta/apps/setupPERMISSION_DENIED,connect_agentabsent from the bodysetup.access+manage_platform_settingsGET /api/v1/meta/apps/setupnav_connect_agentpresent — positive control: the harness does reach the cardapps/setupnot committed.
packages/mcpdeclares no dependency on@objectstack/rest,@objectstack/objectqlor@objectstack/platform-objects, so a permanent both-halves pin cannotlive inside this PR's declared file surface, and this file deliberately does not reimplement the
fold or the filter. See Acceptance notes for where that pin belongs.
The committed pin, and proof it can fail
packages/mcp/src/connect-agent-account-nav.test.ts(7 tests) pins the half this package owns:the contribution aims at the ungated app and group; its item carries nothing the server-side nav
filter could strip for a permissionless caller (
requiredPermissions/requiresService/visible); the Setup entry is byte-unchanged; both contributions parse against the realNavigationContributionSchema; and — the load-bearing one — the bundle declares no permissionkey anywhere and no
appscollection, so it cannot reach the card by widening Setup instead.Two ablations, each committed-first, proven on disk, and restored with
git checkout HEAD --verified by hash equality against the HEAD blob plus an empty
git diff HEAD:setupinstead ofaccount—"app: 'account',"1→0,"app: 'setup',"1→2group: 'group_integrations'dropped from the Setup contribution (a top-level append escapes the group gate) — occurrences 1→0so the test never ran — a void reading, recorded rather than quietly retried, and replaced by the
parse-safe A2 above.
Acceptance notes
packages/cli/test/. Read frompackage.json,packages/cliis the only workspace package depending on@objectstack/mcp,@objectstack/rest,@objectstack/objectqland@objectstack/platform-objectsat once —the same argument
packages/cli/scripts/check-app-nav-i18n.mjsmakes in its own header forliving there ("
cliis the composition root … the ONLY workspace package that depends on alleleven Setup nav contributors at once"). It is outside this PR's declared file surface and
packages/cli/**has siblings in flight this round, so it is reported rather than taken.node packages/cli/scripts/check-app-nav-i18n.mjspasses (10 contributors, 54 mergedsetupnav ids, 4 locales). Measured reason it is silent on the new entry: the gate scopes itself to
APP_NAME = 'setup'andcontinues on any contribution whoseappdiffers, so it demands notranslation key for a contributed account item. Consequence, noted: no
apps.account.navigation.nav_connect_agentlabel exists in any locale, so the entry renders itsEnglish literal under
zh-CN/ja-JP/es-ESwhile the Setup twin renders translated. Thosekeys live in
packages/platform-objects/src/apps/translations/— outside this surface.app-nav-translation-parity.test.tscannot see it either (it walks statically declared navonly, and asserts the reverse direction for
STUDIO_APPalone).packages/mcp/src/plugin.ts:372andpackages/mcp/README.md:92, both directing users to "Setup → Connect an Agent" — becomes truefor non-admins with this change and needs no edit. Recorded so nobody files it twice.
scope: C authors a new panel, this adds a second nav entry to the existing page, and
account.app.tsalready shipsnav_account_api_keysin the exact group targeted here.currently blocked by MCP OAuth cannot complete on 17.3.0: plugin-auth passes
validAudiences, which @better-auth/oauth-provider 1.7.2 no longer reads — everyresource=request fails withinvalid_target … is not configured#16530 and narrowed by OAuth-connected MCP agents run under themcp_agent_data_*ceiling ∩ user, not "as yourself": a viewAllRecords manager sees 5 accounts / 0 opportunities over OAuth vs 9 / 23 over an API key #16549". Both are now closed, so that half of theargument has expired. The ruling does not rest on it — it rests on API keys being per-user
credentials — and re-grading is triage's, not this PR's.
mainmoved under this branch and brought a breaking retirement whose name collides withthe shape used here: feat(spec)!: retire the
type: 'page'list-view mount and itspageNamebinding #17298, "retire thetype: 'page'list-view mount and itspageNamebinding". Read rather than assumed — it touches
packages/spec/src/ui/view.zod.ts, notapp.zod.ts, its own FROM → TO table prescribes "reach the page from the app'snavigation:{ id: 'nav_sales_home', type: 'page', pageName: 'sales_home', label: 'Sales' }" as thereplacement, it calls
PageNavItem.pageName"the page mount that has always rendered", and itstates that the nav twin
validateNavTargetRefsis untouched.origin/mainis merged in hereand the affected slice was re-verified on the merge result.
Gates
58 families derived on the final diff with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, each run withoutput redirected and
$?captured before any pipe, then reconciled with--ran:check:dual-build-cjs-loadsrefused its own prerequisite: it reads built output and 12 packagesoutside this diff's closure have no
dist(@objectstack/studio,@objectstack/client-react,the four connectors, …). Its own text says so — "⛔ This is NOT a pass: nothing was measured."
It needs a repo-wide
pnpm build, which is CI's run; CI checks out and builds fresh. NOTMEASURED, recorded as neither green nor red.
Plus the two the tool does not name:
pnpm lint— run repo-wide to completion(
eslint . --no-inline-config, 2m24s, exit 0), so no narrowing was claimed — andnode packages/cli/scripts/check-app-nav-i18n.mjs(exit 0, verdict line quoted above).Receiving package, re-run on the merge result:
pnpm --filter @objectstack/mcp test— 30 files,320 tests passed — and
pnpm --filter @objectstack/mcp typecheck. The new test file is proven tobe in the type-checked program rather than assumed:
tsc -p tsconfig.test.json --listFilescounts it 1 (and 0 in the main program, which is
tsconfig.json's ownexclude, unchanged).packages/specmoved on the incoming side, sopnpm --filter @objectstack/spec build && check:generatedran on the merge result too.Every heavy run went through
scripts/pm/os-verify-lock.shwithOS_VERIFY_LOCK_SLOT=issue-16746.维护者速读(草稿)
改了什么 — 「连接智能体」这个页面,以前只在「系统设置」里有入口。现在在每个人自己的
「账户 → 开发者」里也加了一个入口,就挨着已经在那里的「API 密钥」。设置里那个入口原样保留,
管理员看到的东西一点没变。
为什么改 — 我们对外说的是「Claude 只能看到和操作您自己有权限的数据」,而后台本来就是
「谁调用就发给谁」。但界面上只有管理员能走到那个按钮,所以普通销售、销售经理照着指南操作,
第一步就卡住了。运行时自己报的错误消息也在把他们指向一个打不开的页面。维护者 9 月 8 日已裁决
按这个方向做(路线 A)。
风险与代价(含回滚) — 后台、授权、对外承诺一个字都没动,没有新增或删除任何权限开关。⚠️ 一个小缺口:这个新菜单项目前没有
唯一的变化是「账户」应用里多了一个菜单项。已实测:没有任何权限的用户现在能看到这个菜单项,
而「系统设置」对他仍然是 403 拒绝——这两件事同时成立,是本次验收的全部内容。回滚就是删掉那
一个对象字面量,一次 revert,无数据迁移、无兼容包袱。
中文/日文/西班牙文标签(翻译文件在另一个包里,不在本 PR 范围),所以在非英文界面下它会显示
英文原文。不影响功能。
席位意见 — (留空,待席位定稿)
你要做的 — 只需确认一件事:把「连接智能体」放进每个人自己的账户页,而不是继续只放在管理员
的系统设置里,符合你 9 月 8 日的裁决意图。其余都是机械落地。
Generated by Claude Code
Generated by Claude Code